#!/usr/bin/env python3

# strict_nsswitch - manipulating PAM configuration in a strict manner
#
# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import re
import pathlib
from ._util import Util
from ._error import ParseError


class NSSwitchConf:

    COMMON_DATABASE_NAMES = [
        "aliases",
        "ethers",
        "group",
        "gshadow",
        "hosts",
        "netgroup",
        "networks",
        "passwd",
        "protocols",
        "publickey",
        "rpc",
        "shadow",
        "services",
    ]

    STATUS_NAMES = [
        "SUCCESS",
        "NOTFOUND",
        "UNAVAIL",
        "TRYAGAIN",
    ]

    ACTION_NAMES = [
        "return",
        "continue",
        "merge",
    ]

    _validStatus = list(STATUS_NAMES)
    for s in STATUS_NAMES:
        _validStatus.append("!" + s)

    def __init__(self, path="/etc/nsswitch.conf"):
        self._path = path
        self._data = dict()
        _parse(self._data, self._path)

    @property
    def path(self):
        return self._path

    @property
    def database_names(self):
        return self._data.keys()

    def has_database(self, database_name):
        return database_name in self._data

    def add_database(self, database_name):
        assert database_name not in self._data
        self._data[database_name] = []

    def remove_database(self, database_name):
        if database_name in self._data:
            del self._data[database_name]

    def set_database_by_line(self, line):
        databaseName, i = _parseDatabase(self._data, None, None, line)

        oldValue = self._data.get(databaseName, None)
        try:
            self._data[databaseName] = []
            i = _parseServices(self._data, None, None, line, i, databaseName)
            assert i == len(line)
        except BaseException:
            Util.dictAssignOld(self._data, databaseName, oldValue)
            raise

    def get_services(self, database_name):
        return self._data[database_name]

    def insert_service_before(self, database_name, index, service):
        assert 0 <= index < len(self._data[database_name])
        assert all([service.name != x.name for x in self._data[database_name]])
        self._data[database_name].insert(index, service)

    def insert_service_after(self, database_name, index, service):
        assert 0 <= index < len(self._data[database_name])
        assert all([service.name != x.name for x in self._data[database_name]])
        self._data[database_name].insert(index + 1, service)

    def remove_service(self, database_name, service_name):
        for i, service in enumerate(self._data[database_name]):
            if service.name == service_name:
                self._data[database_name].pop(i)

    def pop_service(self, database_name, index):
        self._data[database_name].pop(index)

    def set_services_by_line(self, database_name, line):
        oldValue = self._data.get(database_name, None)
        try:
            self._data[database_name] = []
            i = _parseServices(self._data, None, None, line, 0, database_name)
            assert i == len(line)
        except BaseException:
            Util.dictAssignOld(self._data, database_name, oldValue)
            raise

    def clear_services(self, database_name):
        assert database_name in self._data
        self._data[database_name] = []

    def format_line(self, database_name):
        return _genLine(database_name, self._data[database_name], len(database_name))

    def format_file(self):
        databaseNameMaxLen = max([len(x) for x in self._data])
        buf = ""
        for databaseName, serviceObjects in self._data.items():
            buf += _genLine(databaseName, serviceObjects, databaseNameMaxLen)
            buf += "\n"
        return buf

    def save(self, new_path=None):
        if new_path is None:
            new_path = self._path
        pathlib.Path(new_path).write_text(self.format_file())
        self._path = new_path

    def check(self, auto_fix=False, error_callback=None):
        self._bAutoFix = auto_fix
        self._errCb = error_callback if error_callback is not None else _doNothing
        try:
            serviceSet = set()
            for databaseName, serviceObjects in self._data.items():
                for sObj in serviceObjects:
                    if sObj.name not in serviceSet:
                        fn = "libnss_%s.so.2" % (sObj.name)
                        for fullfn in ["/usr/lib/%s" % (fn), "/usr/lib64/%s" % (fn)]:
                            if os.path.exists(os.path.dirname(fullfn)) and not os.path.exists(fullfn):
                                self._errCb("\"%s\" does not exist." % (fullfn))
                        serviceSet.add(sObj.name)
        finally:
            del self._errCb
            del self._bAutoFix


class NSSwitchConfService:

    def __init__(self, name, actions=None):
        self.name = name
        self.actions = actions if actions is not None else dict()

    def _addAction(self, status, action, filePath, row, column):
        if status in self.actions:
            raise ParseError("duplicate status", filePath, row, column)
        self.actions[status] = action


def _parse(dstData, fullfn):
    for lineIndex, line in enumerate(pathlib.Path(fullfn).read_text().split("\n")):
        line, lineNo = line.strip(), lineIndex + 1
        if line == "" or line.startswith("#"):
            continue

        databaseName, i = _parseDatabase(dstData, fullfn, lineNo, line)
        if databaseName in dstData:
            raise ParseError("duplicate database name \"%s\"" % (databaseName), fullfn, lineNo, None)
        dstData[databaseName] = []

        i = _parseServices(dstData, fullfn, lineNo, line, i, databaseName)
        assert i == len(line)


def _parseDatabase(dstData, fullfn, lineNo, line):
    i = line.find(":")
    if i <= 0:
        raise ParseError("no database name", fullfn, lineNo, None)

    databaseName = line[:i]
    i = i + 1                   # jump over ":"

    return (databaseName, i)


def _parseServices(dstData, fullfn, lineNo, line, i, databaseName):
    while i < len(line):
        if line[i] in [" ", "\t"]:
            i += 1
        else:
            i = _parseService(dstData, fullfn, lineNo, line, i, databaseName)

    if len(dstData[databaseName]) == 0:
        raise ParseError("no service found", fullfn, lineNo, i)

    return i


def _parseService(dstData, fullfn, lineNo, line, i, databaseName):
    if line[i] == "[":
        raise ParseError("action must be placed after service", fullfn, lineNo, i)

    # parse service name
    serviceName = ""
    while i < len(line):
        if line[i] in [" ", "\t"]:
            break
        if re.fullmatch("[a-z_]", line[i]) is None:
            raise ParseError("invalid character in service name", fullfn, lineNo, i)
        serviceName += line[i]
        i += 1

    # create and add service object
    assert all([serviceName != x.name for x in dstData[databaseName]])
    dstData[databaseName].append(NSSwitchConfService(serviceName))

    # is there an action following?
    while True:
        if i == len(line):
            return i
        elif line[i] in [" ", "\t"]:
            i += 1
            continue
        elif line[i] == "[":
            break
        else:
            # no action, what we found is the next service
            return i - 1

    # parse actions
    i += 1                          # jump over "["
    while True:
        i = _parseServiceAction(dstData, fullfn, lineNo, line, i, dstData[databaseName][-1])
        if i == len(line):
            raise ParseError("action end characeter not found", fullfn, lineNo, i)
        elif line[i] == "]":
            return i + 1            # jump over "]"
        elif line[i] == ",":
            i += 1
            continue
        else:
            raise ParseError("invalid character", fullfn, lineNo, i)


def _parseServiceAction(dstData, fullfn, lineNo, line, i, curService):
    j = i

    status = None
    if True:
        statusStr = Util.startsWithList(line[j:], [x + "=" for x in NSSwitchConf._validStatus])
        if statusStr is None:
            raise ParseError("invalid status", fullfn, lineNo, i)
        status = statusStr[:-1]
        j += len(statusStr)

    action = ""
    while j < len(line):
        if line[j] in [",", "]"]:
            break
        action += line[j]
        j += 1
    if action not in NSSwitchConf.ACTION_NAMES:
        raise ParseError("invalid action for status \"%s\"" % (status), fullfn, lineNo, i)

    curService._addAction(status, action, fullfn, lineNo, i)
    return j


def _genLine(databaseName, serviceObjects, databaseNameMaxLen):
    buf = ""
    buf += databaseName
    buf += ":"
    buf += " " * (databaseNameMaxLen - len(databaseName))
    for sObj in serviceObjects:
        buf += " "
        buf += sObj.name
        if len(sObj.actions) > 0:
            buf += " ["
            buf += ",".join([f"{k}={v}" for k, v in sObj.actions.items()])
            buf += "]"
    return buf


def _doNothing(msg):
    pass
